查看原文
其他

这个面试中常考的数据结构,你掌握了吗?

银文杰 博文视点Broadview 2022-08-23

👆点击“博文视点Broadview”,获取更多书讯

不知道大家在面试时是否会被问到什么样的哈希数据结构可以保证线程安全?

很多小伙伴可能知道是ConcurrentHashMap,却对其没有太多了解,本文就带大家先来看一下ConcurrentHashMap集合中的size()方法是如何保证准确获取集合大小的。

我们可以思考一个最简单的场景,即调用者如何取得当前ConcurrentHashMap集合中的数据总量?

可能有一些读者会说,直接调用ConcurrentHashMap集合提供的size()方法即可;或者有一些读者会更进一步说,ConcurrentHashMap集合中提供了一个baseCount属性,每当完成添加操作时,ConcurrentHashMap集合都会在addCount方法中利用保证原子性的操作来更新baseCount属性的值。

是的,以上两种描述都没错,但是需要考虑一个前提,那就是ConcurrentHashMap集合工作在高并发场景下,可能随时有多个线程在进行读写操作。

在这样的前提下,再回头看以上两种描述可能就存在问题了。

首先,通过size()方法取得的当前集合中数据总量的值,很可能不是一个精确值,也就是在调用size()方法还未得到返回值时,集合中的数据总量可能就已经发生了变化。

然后,在addCount方法中确实可以利用保证原子性的操作来更新baseCount属性的值,但是若baseCount属性值更新失败了该怎么办?

最直观的处理思路是,一旦baseCount属性值更新失败,就进行重试。但很明显baseCount属性是一个被多线程共享操作的属性,如果采用典型的CAS设计思路——操作失败就重试,那么多线程操作下该值的更新大概率会失败且需要进行多次重试,从而形成并发场景下ConcurrentHashMap集合添加操作的明显性能瓶颈,如下图所示。

为了解决这个问题,最新版本的ConcurrentHashMap集合的主要设计思路是基于线程稳定不变的“探针”功能,设置多个不同的“计数槽”,保证大多数线程在更新计数值时不会产生原子操作冲突。

该部分的设计是ConcurrentHashMap集合中值得读者学习和在实际工作中借鉴的设计思路之一。

(1) ConcurrentHashMap集合中与数量计数有关的属性结构

ConcurrentHashMap集合中与数量计数有关的属性结构主要有3个,如下所示: 

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {// ...... // 基础计数器,在没有并发竞争的场景下,记录目前集合中的数据总量 private transient volatile long baseCount; // 表示目前CounterCells数量计数器是否由于某种原因无法工作。0表示可以工作;1表示不能工作 // 当CounterCells数量计数器被扩容或者被初始化时,该值为1;其他情况下该值为0 private transient volatile int cellsBusy; // 在高并发情况下,集合使用一个CounterCell数组,基于线程“探针”进行数据量的记录 private transient volatile CounterCell[] counterCells; // ......}


(2) CounterCell并发计数器的工作过程概要

ConcurrentHashMap集合的瞬时数据量等于baseCount与CounterCells数组中所有索引位上已记录的值之和。

我们可以看一下ConcurrentHashMap集合的size()方法的源代码:

public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);}

// 该方法是统计集合中数据总量的方法final long sumCount() { // 统计方式是以baseCount属性值为基础的 // 然后累加counterCells每一个索引位上的数值 CounterCell[] cs = counterCells; long sum = baseCount; if (cs != null) { for (CounterCell c : cs) { if (c != null) {sum += c.value;}} } return sum;}

如果ConcurrentHashMap集合工作在一个并发不高的环境中,ConcurrentHashMap集合会在新节点添加到集合中后,直接通过保证原子性的compareAndSetLong方法来完成baseCount计数器的数值增加工作;如果当前集合工作在并发较高的场景中(依据是通过compareAndSetLong方法来更新baseCount计数器时失败),就初始化counterCells数组,并在后续的处理过程中,在counterCells数组特定的索引位增加计数值。

这个过程就是addCount方法中第一个逻辑步骤所希望表达的:

// 该方法主要是为数据量计数器增加数值,并基于当前的数据量确认是否需要进行扩容private final void addCount(long x, int check) { // 这是该方法中的第一个逻辑步骤 // 用来分场景执行数量计数器+1的操作 // 如果当前集合中已经有并发计数器,或者更新baseCount计数器失败 // 就进入这个逻辑过程,将数值增加的情况记录到新的或者已有的counterCells数组中 if ((cs = counterCells) != null || !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {CounterCell c; long v; int m;// 该变量说明这是否是一个默认的无竞争环境 boolean uncontended = true;// 程序代码试图在counterCells数组对象正确、counterCells特定索引位上数据正确// 的前提下直接累加更新这个索引位上的value数据// 如果条件不满足或者更新失败,// 则进入fullAddCount方法,进行一个完整的counterCells初始化和数值累加过程 if (cs == null || (m = cs.length - 1) < 0 || (c = cs[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) { fullAddCount(x, uncontended); return; } if (check <= 1) { return; } s = sumCount(); } // 检查是否进行扩容和数据迁移的逻辑过程 if (check >= 0) { // ...... 这里省略了一些代码 }}


这里有几个关键点需要进行说明。

  • ConcurrentHashMap集合使用counterCells数组而不是baseCount属性记录集合中的键值对数据量,前提条件就是通过compareAndSetLong方法进行baseCount属性的操作时,操作失败。

  • ConcurrentHashMap集合对counterCells数组进行计数增加和扩容操作的处理过程,被放置在fullAddCount方法中。后者也负责counterCells数组的初始化,它将counterCells数组的初始化长度设置为2。counterCells数组的每一个索引位只能通过保证原子性操作的compareAndSetLong方法来进行写处理。

  • 特别注意addCount方法中使用的ThreadLocalRandom类,方法中使用了该工具类提供的getProbe()方法来获取当前线程的“探针”值。这是一个没有开放给最终程序员使用的功能,该功能将为调用者返回一个在当前线程中稳定不变的且全进程唯一的hash值。这个hash值可以在ThreadLocalRandom对象调用advanceProbe方法后发生变化。而当计算某个线程的计数值应该存放在counterCells数组的哪一个索引位时,使用的就是“探针”值和counterCells数组长度通过“与”运算取余数的方式完成的。

  • 虽然counterCells数组的初始化长度只有2,也就是说瞬时只允许两个操作线程对counterCells数组中的不同索引位上的计数值进行成功修改,但该数组是可以被扩容的。每次扩容按照counterCells数组原始容量的2倍进行(即容量始终为2的倍数),且容量的最大上限为当前进程可使用的CPU内核个数(N)——超过当前进程可使用的CPU内核个数的counterCells数组大小是没有意义的,因为不会有那么多同时并发的线程数量。

  • counterCells数组扩容的条件很微妙,它并不是以某个既定的阈值为扩容标准的,而是“以当前进行ConcurrentHashMap集合操作的多个线程,在当前既定容量的counterCells数组的支持下,是否仍然存在抢占冲突的情况”来决定是否进行counterCells数组的扩容的。简单来说,就是执行compareAndSetLong(c, CELLVALUE, v = c.value, v + x)语句时返回了false的场景。

  • 为保证所有操作线程都能了解到counterCells数组目前正在扩容,扩容时(还有counterCells数组初始化时或者counterCells数组某一个索引位初始化时)cellsBusy属性会被标记为1,下图可以描述counterCells数组工作的多个核心要点。

最后,counterCells数组某个索引位上的数据量计数值是由哪个线程执行增加操作的,实际上对于ConcurrentHashMap集合来说并不重要,重要的是保证数值可以正确记录。

也就是说,上一次Thread1完成数据添加后,可能在counterCells数组的0号索引位上进行计数值增加(+1),但是下一次Thread1完成数据添加后,又可能在counterCells数组的3号索引位上进行数值增加(+1)。

以上Thread线程变化所使用的counterCells数组索引位的情况确实是会发生的。

最典型的情况就是当前Thread线程使用原子操作更新某个索引位的计数值失败,但是又因为达到了counterCells数组的扩容上限而无法进行扩容,这时系统就会调用Thread线程对应的ThreadLocalRandom工具类的advanceProbe方法,以改变线程的“探针”值,最终达到更改其使用的counterCells数组索引位的目的。

本文摘自Java高并发与集合框架:JCF和JUC源码分析与实现一书!欢迎阅读此书了解更多相关内容!

《Java高并发与集合框架:JCF和JUC源码分析与实现》

银文杰 著


  • 掌握Java集合框架和Java并发工具包,轻松应对80%的工作场景

本书并不讲解JCF和JUC中各个组件的基本使用方法,因为作者相信JCF和JUC中各种组件的基本使用方法大家通过查看网络资料就可以详细了解。

本书的主要内容是从源代码的层面剖析JCF和JUC的实现原理,以及讲解源代码中蕴含的理论知识,并讲解如何将这些大师级的理论知识应用到实际工作中。

因为是进行源代码分析,所以这本书包含了大量的Java原生模块的源代码,并且进行了逐行注释。请注意是逐行注释,所以在大家阅读本书时,不用担心无法读懂源代码。这解决了很多读者的源代码恐惧症问题。

这些被逐行注释、逐行剖析讲解的源代码,也是本书区别于市面上同类书籍的一大亮点。本书还包含了大量的工作原理图例,这些图例与每一个进行了源码剖析的知识点一一对应,形成了本书独具特点的图文并茂的讲解方式。

例如本书当然会详细剖析ConcurrentHashMap集合的结构和工作过程,但本书更关注ConcurrentHashMap集合为了适应高并发场景所做的典型化设计。


(扫码了解本书详情!)



如果喜欢本文
欢迎 在看留言分享至朋友圈 三连


 热文推荐  





▼点击阅读原文,查看本书详情~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存